Next.js hydration 에러 해결

문제해결
2026년 3월 8일
/Projects/Dev-library/Next.js hydration 에러 해결
목차
1. 문제 인식
2. 문제 상황 재현
3. 문제 원인 파악
3.1. Hydration
3.2. hydration mismatch가 일어나는 상황
3.3. 그럼 현재 상태는 어떤 문제?
4. 에러 해결
5. 느낀점

문제 인식

프로젝트를 진행하던 중 hydration 에러를 만났다.

no image

no image

솔직히 에러 메시지만 보면 대체 왜 에러가 났다는 건 지 제대로 파악이 힘들다. 그냥 얼핏 보기에는 특정 파일에서 에러가 났다 정도만 느낌이 오지, 왜 났는지 쉽게 보이지는 않았다. 또한 새로고침 할 때마다 간헐적으로 발생했기 때문에 더더욱 찾기가 어려웠다. 정확히 어떤 상황에서 나는건지 재현하기 위해 의심가는 코드들을 하나하나 지워가며 새로고침을 통해 에러가 나는지, 나지 않는지 확인했다.

문제 상황 재현

코드를 백업해두고 하나하나 지우며 최대한 간소화 하며 문제가 되는 부분을 좁힐 수 있었다. 다음 코드를 한번 실행해보면 상당히 신기한 현상을 발견할 수 있다. 아래는 실사용 코드가 아닌 테스트용 코드이다.

import { fetchPosts, publishedPosts } from "@/lib/fetch";
import Nav from "./Nav";

export default async function NavDatacontainer() {
  const posts = await fetchPosts("/test");
  console.log(posts);

  return <Nav />;
}

"use client";

export default function Nav() {
  return (
    <div className="h-screen w-0 md:w-64 bg-amber-50 overflow-auto">nav</div>
  );
}

단순히 서버 컴포넌트에서 fetch로 서버 로직을 실행하고, 콘솔로 확인한 뒤 클라이언트 컴포넌트를 리턴하고 있다. 특이한 점은 props로 fetch한 데이터를 넘겨주지도 않았는데도 하이드레이션 에러가 난다는 것이다.

사실 하이드레이션 에러가 나는 이유를 생각해보면, 사실 위 코드에서 에러가 나는건 말이 안된다. 기본적으로 클라이언트 컴포넌트 내에서 서버 렌더링 시 초기 렌더링한 구조와, 브라우저에서 실제로 그렸을 때 차이가 나면 하이드레이션 에러가 발생한다. 하지만 위 코드에서 클라이언트 컴포넌트인 Nav의 경우 완전 정적인 코드여서 서버나 브라우저 렌더링 시 차이가 날 만한 부분이 보이지 않는다.

그렇다면 다음 코드를 보자

import { fetchPosts, publishedPosts } from "@/lib/fetch";
import Nav from "./Nav";

export default async function NavDatacontainer() {
  const posts = await fetchPosts("/test");

  return <Nav />;
}

서버 컴포넌트 코드를 수정했다. 재밌는 점은 콘솔로 확인하는 console.log(posts) 부분만 뺐을 뿐인데, 에러가 나지 않는다는 점이다. 아무리 새로고침을 해도 에러가 다시 나지 않았다. 상당히 당황스럽다. 겨우 로그 하나때문에???

문제 원인 파악

위 상황이 가능한 이유는 바로 테스트 시 production build가 아닌 개발모드 환경에서 실행했기 때문이다. production build가 아닌 dev모드에서는 서버 컴포넌트에서 console.log를 하면, 디버깅이 쉽도록 브라우저에 데이터를 전달해 보여주는 과정에서 에러가 난 것이다. 이 때 데이터는 RSC 페이로드에 추가된다.

no image

no image

아래에 보면 script 태그에 posts 데이터들이 딸려오는 것을 확인할 수 있다. 즉 개발 모드에서 콘솔로 찍는 행위는, 클라이언트 컴포넌트에 props로 데이터를 넘겨주는 것과 같은 맥락으로 이해할 수 있다.

Hydration

에러의 원인을 찾기 위해 우선 사전 렌더링 및 하이드레이션 과정에 대해 살펴보자. (개인적으로 정리한 내용입니다.)

  1. server - root부터 서버 컴포넌트 렌더링 및 RSC 페이로드 생성 시작, 병렬적으로 SSR 엔진이 HTML 생성 시작
  2. server - Suspense 경계 발견 시 청크(Chunk) 분리. 각 chunk는 렌더링 완료 시 따로 stream으로 보내짐. 비동기 작업 시 fallback으로 대체.
  3. server - 클라이언트 컴포넌트 발견 시 props 직렬화 및 클라이언트 참조 추가. SSR 엔진은 계산된 props를 통해 클라이언트 컴포넌트의 초기 정적 HTML 생성.
  4. network - 모든 준비가 끝난 뒤 청크 단위로 즉시 스트림 flush, 이 때 script 태그 내에 RSC 페이로드 데이터 포함.
  5. client - 브라우저는 받은 html을 즉시 화면에 표시, 리액트 런타임 실행 및 RSC 페이로드 기반 React tree 재구성 시작
  6. client - 클라이언트 컴포넌트 참조 정보를 이용해 JS모듈 다운로드, 코드 실행 및 하이드레이션 시작.
  7. client - 초기 HTML과 클라이언트 결과 비교, 불일치 시 하이드레이션 에러 발생. DOM patch 또는 CSR 방식 리렌더링
  8. client - 이벤트 추가 및 1차 하이드레이션 과정 완료
  9. client - 서버에서 Suspense로 만들어지는 다른 바운더리의 남은 chunk들을 받아와 마찬가지 과정으로 hydration 진행

참고:

  • RSC 페이로드의 클라이언트 참조를 바탕으로 build manifest 파일 내에 있는 실제 JS 모듈 path를 가져올 수 있음. 브라우저에서 해당 값을 읽어서 서버로부터 JS파일 로드
  • props에는 함수 등은 직렬화가 불가능한 데이터는 전달할 수 없음.
  • SSR 엔진이 서버에서 클라이언트 초기 렌더링 시 useEffect와 같은 브라우저 로직은 전부 건너뜀, state도 초기값만 사용해서 렌더링
  • props 역직렬화: React는 서버가 보낸 직렬화된 데이터를 다시 JS 객체로 역직렬화하여 클라이언트 컴포넌트의 props로 전달함. 이 값이 SSR 시 사용된 값과 일치해야 하이드레이션 에러가 발생하지 않음.

hydration mismatch가 일어나는 상황

위 과정을 보면 하이드레이션 에러는 초기에 서버에서 SSR 방식으로 생성한 HTML과, RSC 페이로드 정보를 기반으로 새롭게 만들어진 결과가 다를 경우 발생한다. 서버 컴포넌트의 경우 브라우저에서 재실행되지 않기 때문에 외부 개입 없이는 다를 수가 없다. 따라서 일반적으로 하이드레이션 에러는 서버 및 클라이언트 런타임 환경에서 2번 실행되는 클라이언트 컴포넌트에서 발생한다.

클라이언트 컴포넌트에서 에러가 발생하는 상황을 살펴보면 다음과 같다.

  • window 객체, localStorage 등 사용
  • 태그 강제수정: p태그 내에 div 태그 넣으면 브라우저는 알아서 고쳐주지만 리액트는 이해 못함.
  • Date.now() 혹은 랜덤값 등 렌더링 시점에 따라 달라지는 정보가 포함된 경우
  • 브라우저 확장 프로그램이 자동으로 dom 구조를 추가하거나 수정하는 경우
  • 서버와 브라우저의 설정 값 차이로 인해 다르게 표기되는 값들(시간 포맷 등)

즉 서버에서 초기 렌더링 할 때 참고하지 못하는 정보나, 브라우저에서 다른 방식으로 렌더링하는 경우 에러가 발생한다.

그럼 현재 상태는 어떤 문제?

코드를 다시한번 살펴보자

// NavDatacontainer.tsx
import { fetchPosts, fetchTest, publishedPosts } from "@/lib/fetch";
import Nav from "./Nav";

export default async function NavDatacontainer() {
  const posts = await fetchPosts("/test");

  return <Nav posts={posts} />;
}

// Nav.tsx

"use client";

export default function Nav(props: any) {
  return (
    <div className="h-screen w-0 md:w-64 bg-amber-50 overflow-auto">nav</div>
  );
}

클라이언트 컴포넌트는 딱히 렌더링한다고 달라지는 부분이 없다. 따라서 이건 일반적인 에러 상황은 아닌 듯하다. 다만 Nav로 전달하는 props를 조절해서 양을 줄이면 에러가 사라지는걸 확인했다.

  • 테스트1: 데이터 페칭이 오래걸려서 다른 dom 데이터를 먼저 보낼수도 있나? -> 확인해보니 Suspense 없이는 chunk 경계의 데이터 페칭이 전부 완료된 뒤 브라우저로 보낸다. 즉 fetch가 얼마나 오래걸리는지는 관련이 없다.
  • 테스트2: Nav의 props를 의도적으로 많이 늘려봤는데 하이드레이션 에러가 기존보다 빈번하게 늘어남을 확인할 수 있었다.

no image

추정 에러 원인: 처음 에러 메시지를 다시 살펴보면, 여기에는 원래 초록 부분이 있어야 하는데, 왜 빨간 부분이 있지? 라고 말하고 있다. 즉 react 입장에서 서버가 그린 root flex w-screen div를 인식을 못하고 있는 상황이다. 서버가 그린 html를 확인하면 분명히 root div는 존재하는 상황이다. 그런데도 인지를 못하고 있다는 것은 하이드레이션 과정에 문제가 생긴 것이다. 따라서 다음처럼 생각할 수 있다.

과도하게 많은 RSC 페이로드를 서버로부터 받아와 읽는 도중에, 클라이언트 컴포넌트가 하이드레이션 과정을 진행하면서 다시 렌더링할 dom의 위치를 잘못 잡았다....라고 이해하고 일단 넘어가야 할 듯 싶다. 하이드레이션 과정에서 pointer가 하나 밀려있다는 느낌인데, 정확히 위치를 못잡는 원인까지는 파악하지 못했다. 아쉽지만 아무리 찾아도 이 이상의 설명을 찾기가 힘들었다.

에러 해결

Suspense를 통해 비동기 컴포넌트를 감싸 경계를 확실히 해 줌으로써 해결했다.

Suspense 적용 전 초기 html
no image

적용 전에는 그냥 SSR 엔진이 생성해준 html을 그대로 보여주고 있는 모습이다. 이 때 분명 root div가 있는데도 불구하고 추후 하이드레이션 과정에서 해당 div를 인지하지 못하고 하이드레이션 위치가 꼬인다.

Suspense 적용 코드


<html lang="en">
      <body className={`antialiased`}>
        <div className="root flex w-screen">
          <Suspense>
            <NavDatacontainer />
          </Suspense>
          {children}
        </div>
      </body>
    </html>

Suspense 적용 후 초기 html

no image

RSC 데이터를 전부 받아올 때까지 template placeholder로 우선 보여준다.(fallback)

RSC 데이터 로드 완료 후 html
no image

모든 데이터가 로드된 후 하이드레이션이 일어나며 자리를 바꿔치기 해 줬다. 이 때는 제 자리를 잘 찾아서 추가해줌을 확인할 수 있었다.

느낀점

하이드레이션 과정이 정말 복잡하다고 느꼈다.. 나중에 기회가 된다면 next.js의 내부 동작을 직접 뜯어보고 관련 내용을 추가할 예정이다. 또한 비동기 서버 컴포넌트는 웬만하면 Suspense로 전부 감싸서 경계를 정해주는게 Loading ui 등을 통한 사용자 경험에도 좋을 것 같다.